Web APIのトランザクション
更新APIの難しさ
ネットワークの向こう側にあるリソースを更新するのは、単純なTCP/IPの仕組みでは難しい。その上に構成されたシンプルなプロトコルである単純なHTTPで実現しようとすると、以下に示すような箇所でエラーの発生可能性があり、双方で等しく検知することができないケースが存在し、同期的なリカバリが困難である。
エラーの発生箇所
1. クライアント→サーバの接続エラー
2. クライアントからリクエスト送信したがサーバに届かない。
3. サーバが不完全なメッセージを受信した。
4. メッセージがサーバの処理キューに入らない。
5. サーバで処理が正常に完了しない。
6. サーバからレスポンスを返そうとしたが接続が切れている。
7. サーバからレスポンスを返したが、クライアントに届かない
table:エラー検知
クライアント サーバ
① コネクションタイムアウト,... 検知不能
② ソケットタイムアウト,... 検知不能
③ Status code = 400 Status code = 400
④ Status code = 503 Status code = 503
⑤ Status code = 5xx Status code = 5xx
⑥ 検知不能 コネクションリセット,...
⑦ 検知不能 ソケットタイムアウト,...
冪等な仕組みがないケースでのリトライしてもよいケースは、①, ④, (⑤)のときのみ。
冪等のユースケースと対処の仕組み
この問題に対しての基本的な戦略は冪等な仕組みを作って、成功するまでリトライすることだ。
HTTPにおけるリソース更新(PUT/PATCH/POST/DELETE)の冪等な仕組みは、以下のガイドラインが詳しい。
いくつかの実現したいユースケースが存在する。
1. ゾンビリソース(同じ内容で複数レコード作られれる)を防ぐ
例) 商品注文のAPIでクライアントの何らかの原因で同じ内容のリクエストが同時に複数来た場合でも、サーバサイドではただ1つの注文として扱われる
2. 同時更新で何れかの更新内容が失われるのを防ぐ
例) Aさん、Bさんが同時に、Cさんのデータを同じタイミングで参照し更新する。AさんはCさんの名前をDに変更してPUTする。その直後BさんがCさんの年齢だけを修正してPUTする。Bさんのリクエストが後で実行されると、Aさんの変更した名前Dは、Cに戻ってしまう。これが更新データのロストである。
3. 安全にリトライできる(リトライによる不整合リスクがない)
例) 商品注文のAPIで最初のリクエストでソケットタイムアウトしたので、リトライをすると、最初のリクエストも実際にはサーバサイドで注文処理が行われており、二重注文が発生してしまう。クライアントがこれを気にすることなく、リトライしたい。
4. 同じリクエストに対しては、正確に同じレスポンスが返る
例) 商品注文確定のボタンをダブルクリックして、2件の注文リクエストが発生した場合、サーバサイドで1つだけ正常に処理し、以降をエラーを返すような対処がされていると、結果として注文は正常に受け付けられたが、ユーザにはエラーが返る(代表的にはCSRFトークンエラー)。そうなるとユーザは、注文が完了できなかったと思い込み、再度同じ商品を注文してしまう可能性がある。これを避けるには同じレスポンスを返したい。
冪等性と安全性
安全性: リソースの状態の変化を伴わないこと。
冪等性: 同一のリクエストを何度実行してもリソースの状態は同じであること。
Conditional Request
データ更新時に、主に更新のロストを防ぐ目的でこれを実装する。
HTTP標準のETagとIf-Matchによって実現する。
一度サーバからエンティティを取得する。
このときETagがついてくる。
ETagは楽観排他のバージョンみたいなもの。
更新するときに、If-Matchを使ってETagを送る。
ETagにマッチしたら更新を実施する
そうでなければ412を返す。
セカンダリキー
データ登録時に、同内容のレコードが複数作られることを防ぐ目的でこれを実装する。
エンティティに一意のキーをつける。
POSTリクエストが来たときに、セカンダリキーで検索し、あればConflictを返す。
クライアントがID付けると、別IDで同じ内容が飛んでくる可能性がある。
考えられるユースケース
ロングタームイベントパターン
親のイベントIDを渡す。
冪等キー
キーはクライアントが生成する。
IETFのドラフトにもなった
以下、IETFの記述内容を要約
クライアントはHTTPヘッダにIdempotency-Key: 一意性が担保されたID(通常UUID)を付ける
サーバ側では、リクエストの処理時にIdempotency-keyを元に同じ処理が2回以上されないに設計する。
データベースのテーブルにIdempotency-keyを保存するカラムを追加し、一意制約を付与することで担保することが多い
メッセージ配信のメカニズム
at-most-once (最大1回)
届かないかもしれないが、重複はしない。
at-least-once (少なくとも1回)
重複するかもしれないが、届くことは保証する
exactly-once (正確に一回)
重複なくただ1回だけ届くことが保証される。
冪等キーが実装されていれば、重複はなくなるので考えるべきは、届くことが保証できるかどうか。
At-least-onceの実現
Conditional Requestとセカンダリキーによって、安全にリトライできるので成功するまで繰り返せばよい… が、これを保証するためには同期的な仕組み、すなわちRequest-ReplyのHTTPで実現するのは現実的でない(サーバがダウンしいる間、ずっと処理完了にできなくなる)。
複数API呼び出しにおけるトランザクション
https://gyazo.com/4ea228bbdfc74ef7f94518e92e601719
複数APIを呼び出して、整合性をとった更新を行う場合は、話は複雑になる。
Ignore Errors
エラーをリカバリするほうがコストがかかるケースにおいては、エラーを無視する戦略もある
例) スターバックス
Compensation Action(補償アクション)
https://gyazo.com/9389cc24acd766e36464cd9d52d44bec
順々に呼び出し、成功しないもの1つでもがあれば補償アクションを呼び出す。
補償アクションはAt-least-onceでなくてはならないので、イベント駆動、メッセージキューを使う。
例: 旅行予約
補償アクションでよく登場する例が旅行予約で、カスタマがすでにホテルを予約したが、フライト予約を行えない場合、ホテルをキャンセルして予約を「補償」する。また、ホテルや航空会社は、オーバープロビジョニングによって予約がキャンセルされる可能性もある。つまり、部屋または座席の予約数が利用可能な部屋または座席の数を超えることがある。キャンセルが予想より少ない場合、ホテルは代替品を提供し、航空会社は座席を放棄する意思のある乗客に対して補償を提供する。ホテルや航空会社は、オーバープロビジョニングやアンダープロビジョニングのコストを軽減するため、キャンセル不可の予約の価格を下げるなどのインセンティブも定期的に提供している。
Tentative Operation
https://gyazo.com/249fbf0052d669ce926f85bf5ece50f2
仮トランザクションは、例えば注文であれば「注文テーブル」にINSERTするのではなく、「仮注文テーブル」にINSERTする。
Confirmイベントを受信したら、仮注文テーブルから注文テーブルにINSERT~SELECTし、仮注文テーブルから削除する。
Cancelイベントを受信したら、仮注文テーブルからその仮注文レコード削除する。
Confirm/CancelはAt-least-onceでなくてはならないので、イベント駆動、メッセージキューを使う。
APIのコール順序
できるかぎり並列実行するが、リカバリにそれなりのコストがかかる場合は、以下の戦術を用いる。
Perform Most Likely to Fail Action First (一番失敗する確率の高いものから実行)
Perform Hardest to Revert Action Last (リカバリが困難なものを最後に実行)